너 왜 노냐..?
평화로운 아침, 멀쩡하던 애플리케이션이 갑자기 예외를 뿜어내며 요청에 실패하기 시작했습니다.
로그를 확인한 결과, 예외가 발생한 곳은 간단한 JPA 쿼리였습니다.
전혀 문제가 발생할 소지가 없어 보였던 코드였기 때문에 영문을 모르고 삽질을 반복한 결과,
HikariPool 데드락으로 인해 발생한 문제였습니다.
HikariPool 데드락을 요약하자면 하나의 작업에서 필요한 커넥션의 양보다 HikariPool Size가 적으면 발생하게 되는 교착 상태입니다.
쉽게 설명하기 위해 예시를 들어 보겠습니다.
유저에게 요청을 받아 어떠한 작업을 처리하는 Thread가 있다고 가정하겠습니다.
이 녀석은 용량이 10인 HikariPool에서 커넥션을 하나 가져와 작업을 진행한 후, 또 다른 커넥션을 가져와 작업을 하나 더 하고 나서야 커넥션을 반환하는 욕심 많은 녀석입니다.
평소에는 필요한 커넥션에 비해 용량이 충분했기 때문에 별 문제가 없었지만, 어느 날 동시에 10개의 요청이 들어오게 됩니다.
요청을 처리하기 위해 10개의 Thread가 생성되고, 전부 동시에 첫 번째 작업을 받아 열심히 진행합니다.
그런데 어느 순간부터 모든 Thread가 게으름을 피우며 놀고 있습니다. 왜일까요?
이 Thread는 두 번째 작업까지 완료된 후에만 첫 번째 작업의 커넥션을 반환하기 때문에, 모든 Thread가 HikariPool의 용량만큼 첫 번째 커넥션을 가지게 되면 아무도 두 번째 커넥션을 받지 못해 작업이 진행되지 않습니다.
모두가 두 번째 커넥션을 받기만을 기다리는 교착 상태, 즉 데드락이 발생한 것입니다.
위에 태그한 우아한 형제들 기술 블로그에서는 MySQL @GeneratedValue로 인해 발생한 데드락이었지만, 제 경우는 조금 달랐습니다.
범인은 OSIV
Spring Data JPA는 기본적으로 OSIV 설정을 활성화합니다.
OSIV (Open-Session-In-View, 요청 당 세션)는 영속성 세션과 요청 생명 주기를 연결하는 트랜잭션 패턴으로, 쉽게 설명하자면 OSIV 설정이 활성화된 경우, Spring JPA는 영속성 컨텍스트를 클라이언트에게 응답할 때까지 유지한다고 생각하면 됩니다.
Spring은 요청이 시작될 때 OSIVFilter에서 Hibernate 세션을 열고, 요청 생명 주기 안에서 해당 세션을 계속 재사용합니다.
그렇기 때문에 열린 세션은 클라이언트에게 응답할 때까지 닫히지 않으며, 열린 커넥션 (즉, HikariPool에서 빌려온 커넥션)도 반환되지 않습니다.
그리고.. 문제는 여기서 발생합니다.
예외가 발생한 코드는 대량의 요청을 받아 처리하는 코드로, 리팩토링 및 성능 개선을 거치며 단순 반복에서 CompletableFuture와 @Async를 사용해 ThreadPool로 비동기 처리를 하는 방식으로 변경되었습니다.
리팩토링 중 로직을 모든 비동기 Thread가 사용하는 공통 로직과 비동기 처리 로직으로 나누었는데, 눈치 빠른 분들은 벌써 눈치채셨겠지만 여기가 문제였습니다.
@Async로 ThreadPool에서 가져온 Thread는 Controller에서 요청 시 생성된 Thread와 별도의 Thread이기 때문에, OSIVFilter에서 생성된 Hibernate 세션을 공유하지 않습니다.
따라서 요청 시 공통 로직 (Controller Thread)과 비동기 처리 로직 (ThreadPool Thread)에서 각각 다른 커넥션을 사용하게 되고, HikariPool 용량 이상의 요청을 받을 경우 공통 로직에서 커넥션을 모두 물고 있어 데드락이 발생하게 된 것입니다.
해결 방법
이 문제의 해결 방법에는 세 가지가 있습니다.
1. HikariPool 용량을 적절하게 늘린다.
공식은 Thread 개수 X (동시 사용 연결 수 - 1) + (Therad 개수 / 2)
데드락을 회피할 수 있을 만큼 Pool 용량을 늘리는 방법으로, 위의 우아한형제들 기술 블로그에도 나온 방법입니다.
성능 측정이 어느 정도 되어 있고, Thread 개수와 동시 사용 연결 개수를 명확히 정의할 수 있다면 가장 깔끔한 방법이라고 생각됩니다.
만약 데이터베이스의 동시 연결 수가 제한되어 있는 상황이라면 사용이 어려울 수 있습니다.
2. 비동기 처리 로직에서만 DB 커넥션을 사용한다.
가장 쉬운 방법입니다.
사이드 이펙트도 걱정할 필요 없고, 코드 단에서만 수정하면 되는 장점이 있습니다.
하지만 제 상황과 같이 비동기 처리 전에도 커넥션을 사용해야 한다면 사용할 수 없는그리고 일이 늘어나는 방법입니다.
3. OSIV 설정을 비활성화한다.
제가 선택한 방법으로, 아예 OSIV 설정을 비활성화하는 방법입니다.
OSIV가 비활성화되면 트랜잭션 종료 시 영속성 컨텍스트를 닫고 커넥션을 반환하기 때문에, 데드락 없이 커넥션을 사용할 수 있습니다.
하지만 지연 로딩 또한 트랜잭션 안에서만 작동하기 때문에, 지연 로딩 관련 코드의 트랜잭션 처리를 꼼꼼히 해야 할 필요가 있습니다.
3줄 요약
- HikariPool 커넥션을 제대로 반환하지 않을 시 교착 상태 발생
- Data JPA OSIV 설정이 켜져 있으면 요청에 응답할 때까지 Hibernate 세션 유지
- @Async는 별도 Thread (즉, 별도의 Hibernate 세션)를 생성하기 때문에 OSIV가 켜진 상태에서는 HikariPool 데드락 발생 가능